"""Scene summaries and graph browse over a Cinema 4D document.

One tool body, two transports (the blender_mcp discipline): the live bridge
runs these against the open document on the main thread, and the CLI's c4dpy
lane runs the same functions against `c4d.documents.LoadDocument(file)`.
Pure functions over a `BaseDocument` — no globals, no GUI, JSON-ready output.
"""

from __future__ import annotations


def _type_name(node) -> str:
    try:
        return node.GetTypeName()
    except Exception:
        return type(node).__name__


def _walk(objects):
    for node in objects:
        yield node
        yield from _walk(node.GetChildren())


def summarize(doc) -> dict:
    """Whole-file counts — the `get_blendfile_summary_datablocks` analog."""
    fps = doc.GetFps()
    objects = list(_walk(doc.GetObjects()))
    by_type: dict[str, int] = {}
    tags = 0
    keyframed = 0
    for node in objects:
        by_type[_type_name(node)] = by_type.get(_type_name(node), 0) + 1
        tags += len(node.GetTags())
        if node.GetCTracks():
            keyframed += 1
    materials = [material.GetName() for material in doc.GetMaterials()]
    return {
        "name": doc.GetDocumentName(),
        "path": doc.GetDocumentPath(),
        "fps": fps,
        "frameRange": [doc.GetMinTime().GetFrame(fps), doc.GetMaxTime().GetFrame(fps)],
        "objects": len(objects),
        "objectsByType": dict(sorted(by_type.items(), key=lambda item: (-item[1], item[0]))),
        "keyframedObjects": keyframed,
        "tags": tags,
        "materials": len(materials),
        "materialNames": materials[:50],
        "topLevel": [node.GetName() for node in doc.GetObjects()],
    }


# --- op registry --------------------------------------------------------------
# Both transports dispatch scene ops from here: the live bridge (bridge.py
# drain) and the CLI's c4dpy lane look ops up by name in OPS. Each op is
# `fn(doc, params) -> JSON-ready value`. New ops are added with register_op —
# the bridge's socket code never changes for a new op.

OPS: dict = {}


def register_op(name: str, fn) -> None:
    """Register `fn(doc, params) -> JSON-ready value` under a bridge/CLI op name."""
    OPS[name] = fn


def graph(doc, max_depth: int | None = None) -> list[dict]:
    """The object tree with tags, material links, and animation flags."""

    def node_entry(node, depth: int) -> dict:
        entry = {
            "name": node.GetName(),
            "type": _type_name(node),
            "tags": [_type_name(tag) for tag in node.GetTags()],
            "materials": [
                tag.GetMaterial().GetName()
                for tag in node.GetTags()
                if hasattr(tag, "GetMaterial") and tag.GetMaterial() is not None
            ],
            "animated": bool(node.GetCTracks()),
        }
        children = node.GetChildren()
        if children and (max_depth is None or depth < max_depth):
            entry["children"] = [node_entry(child, depth + 1) for child in children]
        elif children:
            entry["childCount"] = len(children)
        return entry

    return [node_entry(node, 1) for node in doc.GetObjects()]


def _basename(path: str) -> str:
    return path.replace("\\", "/").rsplit("/", 1)[-1]


def assets(doc) -> dict:
    """External files this document references — the missing-files twin.

    Cinema 4D's own asset walker sees every channel (materials, Redshift nodes,
    XRefs, caches), which no hand-rolled shader walk does.
    """
    import os

    import c4d
    import c4d.documents

    walker = getattr(c4d.documents, "GetAllAssetsNew", None)
    if walker is None:
        raise RuntimeError("This Cinema 4D build has no asset walker (GetAllAssetsNew).")
    # The signature drifted: 2026 fills the `assetList` argument and returns a
    # GETALLASSETSRESULT code (a bare 3-arg call hands back an int), while older
    # builds return the list itself. Measured on 2026.3, 2026-08-06.
    collected: list = []
    try:
        outcome = walker(doc, False, "", getattr(c4d, "ASSETDATA_FLAG_0", 0), collected)
    except TypeError:
        outcome = walker(doc, False, "")
    seen = set()
    entries = []
    for asset in (outcome if isinstance(outcome, (list, tuple)) else collected):
        path = str(asset.get("filename") or "")
        owner = asset.get("owner")
        owner_name = owner.GetName() if hasattr(owner, "GetName") else None
        key = (path, owner_name)
        if not path or key in seen:
            continue
        seen.add(key)
        exists = asset.get("exists")
        entries.append(
            {
                "path": path,
                "name": _basename(path),
                "owner": owner_name,
                "exists": bool(os.path.isfile(path) if exists is None else exists),
            }
        )
    missing = [entry for entry in entries if not entry["exists"]]
    return {"assets": entries, "missing": missing, "count": len(entries), "missingCount": len(missing)}


def xrefs(doc) -> dict:
    """XRef objects and the documents they pull in — the linked-libraries twin."""
    import c4d

    xref_type = getattr(c4d, "Oxref", 1025766)
    file_id = getattr(c4d, "ID_CA_XREF_FILE", None)
    entries = []
    for node in _walk(doc.GetObjects()):
        if node.GetType() != xref_type:
            continue
        path = ""
        if file_id is not None:
            try:
                path = str(node[file_id] or "")
            except Exception:
                path = ""
        entries.append(
            {
                "name": node.GetName(),
                "path": path,
                "file": _basename(path),
                "children": len(node.GetChildren()),
            }
        )
    return {"xrefs": entries, "count": len(entries)}


# What a scene is FOR, guessed from what it contains. Ordered: the first rule
# whose signals fire wins, so the most specific shape is reported.
_USAGE_RULES = (
    # Keywords are substring matches on the type name, so they must be long
    # enough not to collide — "ik" would classify a Spike deformer as a rig.
    ("character animation", ("joint", "skin", "character")),
    ("motion graphics", ("cloner", "mograph", "effector", "fracture", "matrix")),
    ("simulation", ("emitter", "particle", "pyro", "cloth", "rigid", "fluid", "dynamics")),
    ("lighting / lookdev", ("light", "sky", "hdri", "area")),
    ("camera layout", ("camera", "stage", "target")),
)


def usage(doc) -> dict:
    """A one-line guess at what this document is for, plus the evidence."""
    summary = summarize(doc)
    by_type = {name.lower(): count for name, count in summary["objectsByType"].items()}
    reasons = []
    guess = "modelling"
    for label, keywords in _USAGE_RULES:
        hits = {name: count for name, count in by_type.items() if any(word in name for word in keywords)}
        if hits:
            guess = label
            reasons = [f"{count}× {name}" for name, count in sorted(hits.items())]
            break
    if summary["keyframedObjects"] and guess == "modelling":
        guess = "animation"
        reasons = [f"{summary['keyframedObjects']} keyframed objects"]
    return {
        "guess": guess,
        "reasons": reasons,
        "objects": summary["objects"],
        "animated": summary["keyframedObjects"],
        "materials": summary["materials"],
        "frameRange": summary["frameRange"],
    }


def _find(doc, name: str):
    for node in _walk(doc.GetObjects()):
        if node.GetName() == name:
            return node
    return None


def _matrix_rows(node) -> list | None:
    matrix = node.GetMg() if hasattr(node, "GetMg") else None
    if matrix is None:
        return None
    v1, v2, v3, off = matrix.v1, matrix.v2, matrix.v3, matrix.off
    return [
        [v1.x, v2.x, v3.x, off.x],
        [v1.y, v2.y, v3.y, off.y],
        [v1.z, v2.z, v3.z, off.z],
    ]


def object_detail(doc, name: str) -> dict:
    """Everything about one object — the get_object_detail_summary twin."""
    node = _find(doc, name)
    if node is None:
        raise RuntimeError("No object named '%s' in this document." % name)
    parents = []
    parent = node.GetUp() if hasattr(node, "GetUp") else None
    while parent is not None:
        parents.append(parent.GetName())
        parent = parent.GetUp()
    detail = {
        "name": node.GetName(),
        "type": _type_name(node),
        "path": list(reversed(parents)) + [node.GetName()],
        "children": [child.GetName() for child in node.GetChildren()],
        "tags": [_type_name(tag) for tag in node.GetTags()],
        "materials": [
            tag.GetMaterial().GetName()
            for tag in node.GetTags()
            if hasattr(tag, "GetMaterial") and tag.GetMaterial() is not None
        ],
        "animated": bool(node.GetCTracks()),
        # Named where the track exposes one — an exotic track type must not
        # take the whole report down.
        "tracks": [track.GetName() if hasattr(track, "GetName") else "track" for track in node.GetCTracks()],
    }
    rows = _matrix_rows(node)
    if rows is not None:
        detail["matrix"] = rows
    for key, getter in (("points", "GetPointCount"), ("polygons", "GetPolygonCount")):
        if hasattr(node, getter):
            detail[key] = int(getattr(node, getter)())
    if hasattr(node, "GetRad") and hasattr(node, "GetMp"):
        radius, center = node.GetRad(), node.GetMp()
        detail["bounds"] = {
            "center": [center.x, center.y, center.z],
            "size": [radius.x * 2, radius.y * 2, radius.z * 2],
        }
    return detail


def save_copy(doc) -> dict:
    """Write the OPEN (possibly unsaved) document to a temp copy and return it.

    The CLI's headless lane calls this when the live bridge reports the target
    file open and dirty — c4dpy would otherwise read the stale bytes on disk.
    The path is generated HERE, never taken from the request: the bridge must
    not become a write-anywhere primitive.
    """
    import os
    import tempfile

    import c4d
    import c4d.documents

    handle, path = tempfile.mkstemp(prefix="tb-c4d-live-", suffix=".c4d")
    os.close(handle)
    saved = c4d.documents.SaveDocument(
        doc, path, c4d.SAVEDOCUMENTFLAGS_DONTADDTORECENT, c4d.FORMAT_C4DEXPORT
    )
    if not saved:
        raise RuntimeError("Cinema 4D could not save a copy of the open document.")
    return {"path": path, "document": doc.GetDocumentName()}


register_op("summary", lambda doc, params: summarize(doc))
register_op("graph", lambda doc, params: {"graph": graph(doc, max_depth=params.get("depth"))})
register_op("assets", lambda doc, params: assets(doc))
register_op("xrefs", lambda doc, params: xrefs(doc))
register_op("usage", lambda doc, params: usage(doc))
register_op("object", lambda doc, params: object_detail(doc, params.get("name") or ""))
register_op("save_copy", lambda doc, params: save_copy(doc))
